操作系统 - 虚拟内存
Table of Contents
Table of Contents
本篇围绕《操作系统导论》(OSTEP,王海鹏译)第 13—24 章的内存虚拟化(Virtualization: Memory)展开。先弄清程序眼里的地址,再看硬件怎样转换地址、操作系统怎样管理空间,最后讨论内存不够时怎么办。
期中导引: 本篇系统对应 OSTEP 内存虚拟化核心篇章(第 13—24 章),深入推导地址转换、空闲块管理、分页、TLB、多级页表、缺页异常处理与置换策略。配套题型演练详见 期中复习与典型题,涵盖多级页表 EAT 计算、置换序列推导与内核地址转换代码分析。持久化外存内容详见 存储与持久性基础。全局导航参见 期中学习导航。
教材与英文课件怎么对照
以中文版教材为主线,同时参考 OSTEP 官网收录的 Youjip Won 英文章节 PPT,并对照 Andrea Arpaci-Dusseau 的 CS 537 课程。Youjip 的课件最初用于汉阳大学课程,按 OSTEP 章节编排;下文分别注明教材章节、课件作者与页序。
| 读到的核心问题 | 中文教材 | 英文 PPT | 本篇位置 |
|---|---|---|---|
程序看到什么地址?malloc 给了我什么? | 第 13—14 章 | Address Space / Memory API | §1、§6.3 |
| 每次取指、读写怎样转换地址? | 第 15—16 章 | Address Translation / Segmentation | §2.1—2.2 |
| 大小不同的空闲块,怎样分配和收回? | 第 17 章 | Free-Space Management | §2.3—2.6 |
| 页、页框和页表分别是什么? | 第 18 章 | Paging: Introduction | §3 |
| 怎样少查页表、少存页表? | 第 19—20 章 | TLB / Paging: Smaller Tables | §4 |
| 页不在内存时怎么办,淘汰谁? | 第 21—22 章 | Swapping: Mechanisms / Policies | §5、§7 |
| 上面的零件怎样组成一个系统? | 第 23—24 章 | 本篇以教材案例为准 | §6、§8 |
下文页码分别标注书内页码与PDF 页序,指本地王海鹏译本。英文公开版可能更新,查英文材料时优先按章名。例题中另行说明页大小、地址宽度和分配规则,不默认所有机器都使用 4 KiB 页。
知识演进脉络:为什么会有虚拟内存?
理解虚拟内存的关键,不是死记各章节的术语,而是理解硬件与操作系统为了解决上一步留下的缺陷,如何自然推演出下一步的机制:
[物理直连寻址]
│ 缺陷:程序互相踩踏,无重定位,无保护隔离
▼
[动态重定位:基址与界限] (Base & Bounds)
│ 缺陷:必须分配整段连续物理空间;堆与栈之间巨大的空闲区域白白占着物理内存
▼
[分段机制] (Segmentation)
│ 思想:为代码、堆、栈分别提供基址与界限
│ 缺陷:各段长短不一,变长分配导致内存产生大量无法利用的【外部碎片】
├─► 空闲空间管理:分割、合并、首次/最佳/最差/下次匹配
│ 缓解变长分配的碎片;分页系统中的 malloc 仍然需要这一层
▼
[分页机制] (Paging)
│ 突破:按固定大小页映射到离散页框,消除基本页分配对大块连续物理空间的要求
│
├─► 矛盾 1:访存变慢(每次取数据要先查一次页表) ──► 解决方案:【TLB 快表】(利用局部性缓存转换)
└─► 矛盾 2:页表过大(单级页表每进程需数 MB) ──► 解决方案:【多级页表】(树状按需分配,折叠空闲空间)
│
▼
[超越物理内存] (Swapping)
│ 突破:物理内存装不下时,以磁盘作为后备存储(Swap)
├─► 机制:PTE 存在位(Present Bit)+ 缺页异常(Page Fault)触发内核按需调入
└─► 策略:页面置换算法(OPT 理论最优、FIFO 与 Belady 异常、LRU 局部性、Clock 时钟工程折中)
│
▼
[现代机制与实践]
写时复制(COW)、按需分配零页、用户态内存分配器(malloc/free/brk/mmap)与保护边界
⭐ 核心考试重点:
- 虚拟地址到物理地址的转换模型(基址界限、分段寻址、分页切分)
- 页表项(PTE)核心标志位功能(Valid, Present, R/W, Dirty, Accessed/Reference)
- 分页机制的两大代价及其应对方案(空间代价 多级页表;时间代价 TLB 快表)
- TLB Miss(快表未命中)与 Page Fault(缺页异常)的本质辨析
- 两级/多级页表寻址、索引划分与空间节省手算
- 缺页异常处理全流程(Page Fault Handling 7 步时序)
- 页面置换算法(OPT, FIFO, LRU, Clock)的手算追踪与 Belady 异常原理
- 有效访存时间(EAT / AMAT)综合计算模型
一、地址空间抽象:为什么需要虚拟内存?
在早期多道程序设计之前,程序直接在物理地址(Physical Address)上运行。这意味着:
- 编译时必须固定程序的物理加载地址;
- 多个程序共享物理内存时,一个程序的指针越界就会直接破坏另一个程序甚至操作系统的核心数据;
- 进程无法动态扩展内存。
为了解决这一问题,操作系统引入了核心抽象:虚拟地址空间(Virtual Address Space)。
1.1 地址空间的概念与三大设计目标
虚拟地址空间是操作系统为每个运行中的进程提供的一种抽象:程序可以使用自己的地址编号,不必知道数据实际放在哪块物理内存里。教材从地址 0 开始画示意图;实际进程只映射其中一部分地址,地址 0 通常不可访问,代码也未必从最低地址开始。连续的地址编号不等于每个地址都合法,更不等于物理内存连续。
操作系统在实现虚拟内存时,必须兼顾三大设计目标:
- 透明性(Transparency):程序无需关心物理内存的存在。编译、链接和执行完全基于虚拟地址展开。
- 效率(Efficiency):
- 时间效率:虚拟地址到物理地址的转换必须依赖硬件(MMU/TLB)极速完成,不能拖慢 CPU 指令流水线。
- 空间效率:维护地址转换所需的元数据(如页表)自身不能过多浪费宝贵的物理内存。
- 保护与隔离(Protection / Isolation):每个进程运行在受保护的“沙盒”内,任何非法的内存读写只能波及自身,绝不能侵犯其他进程或操作系统内核。
1.2 进程虚拟地址空间的逻辑布局
一个经典进程的虚拟地址空间通常划分为以下逻辑区域:
0x00000000代码段 (Text):机器指令,只读
数据段 (Data/BSS):全局/静态变量
malloc)向高地址方向连续增长
↓
保证堆与栈在运行中有充足扩展余地
向低地址方向动态增长
↑
实际系统还包含共享库、文件映射与受保护的内核区域
1.3 概念透视:虚拟内存大小(VSZ)vs 常驻物理内存(RSS)
在命令行工具中,我们经常能观察到虚拟内存抽象带来的直观现象:
$ ps -o pid,vsz,rss,command -p $$
PID VSZ RSS COMMAND
74503 488781600 1248 /bin/zsh -i
- VSZ (Virtual Memory Size):
488781600 KiB ≈ 466 GiB。它统计进程的虚拟地址范围,可能包含大量预留和共享映射,具体口径依平台而异。预留虚拟空间不要求等量物理 RAM,但页表、内核记账和已经驻留的页仍会消耗物理内存。 - RSS (Resident Set Size):
1248 KiB ≈ 1.2 MiB。这是该进程当前实际加载并驻留在物理内存 RAM 中的物理页框总和。
二、地址转换与变长空间管理:基址界限、分段与空闲块
为了把虚拟地址安全、高效地转换为物理地址,硬件和操作系统经历了一步步演进。
2.1 起点:动态重定位(基址与界限 Base and Bounds)
最简单的转换机制是硬件动态重定位。CPU 的内存管理单元(MMU)配置两个专用寄存器:
- 基址寄存器(Base Register):记录进程在物理内存中的起始物理地址。
- 界限寄存器(Bounds / Limit Register):记录进程地址空间的大小。
0 ≤ VA < Bounds?若
VA ≥ Bounds,触发越界异常,交给内核处理。
$$PA = \text{Base} + VA$$
机制的致命缺陷:
基址与界限机制要求为整个地址空间分配一块连续的物理内存。
然而,正如前文所见,堆和栈之间存在巨大的空洞(Free Space)。系统为了满足基址界限的连续性,不得不把未使用的空洞也整块塞进物理内存,导致了物理空间的严重浪费。
地址转换是谁做的?先算一个边界例题
设 Base 为 32 KiB,Bounds 表示长度 16 KiB。教材模型中的合法虚拟地址是 :
| 访问的虚拟地址 | 检查 | 结果 |
|---|---|---|
| 256 | ||
| 16383 | 最后一个合法字节 | |
| 16384 | 等于长度,已经越界 | 触发异常,不能继续访存 |
Bounds 如果存的是长度,检查的是“小于”,不是“小于等于”。 有的图把界限画成物理结束地址,此时比较式不同,做题先看定义。上面的公式还只检查单字节;多字节访问必须保证整个访问范围合法。
普通访存由硬件完成检查与转换,不需要每次调用操作系统。内核主要在以下时刻介入:创建进程时分配空间并设置寄存器;上下文切换时保存旧进程、恢复新进程的 Base/Bounds;进程退出时回收空间;访问越界时处理异常。用户态不能任意修改这对寄存器,否则保护就失效了。
还要把取指令算进去。执行一条“从内存读数据”的指令,既要取指令,也要读操作数,两处地址都需要转换;纯寄存器加法没有数据访存,但仍要取指。可以连着读已有的计算机系统:程序执行,把 PC、load/store 与地址转换接起来。
对应教材 §15.3—15.5(书内第 103—108 页,PDF 第 115—120 页);Youjip 第 15 章 PPT 第 4—16 张。边界数值为自设。
2.2 演进:分段机制(Segmentation)
技术直觉:既然堆与栈之间的空洞浪费了连续内存,为什么不把地址空间拆成独立的几个逻辑块(代码段、堆段、栈段),让每个段拥有自己独立的基址与界限?
这就是分段(Segmentation)的思想:
- 内存管理单元不再只有一对 Base/Bounds,而是为每个逻辑段维护一组段寄存器(Base、Bounds、生长方向、读写执行权限)。
- 虚拟地址被划分为两部分:高位表示段号(Segment ID),低位表示段内偏移(Offset)。
高 2 位(本例 00=代码, 01=堆, 11=栈)
低 12 位(寻址段内具体字节)
生长方向: 正向 (+)
权限: Read / Execute
生长方向: 正向 (+)
权限: Read / Write
生长方向: 反向生长 (-)
权限: Read / Write
本例 Base 指向栈区域的高端、作为不包含的上界。利用
Negative Offset = Offset - Max Segment Size 计算负向偏移,物理地址为 PA = Base + Negative Offset,合法范围为 -Bounds ≤ Negative Offset < 0。
分段例题:为什么不能直接把整个虚拟地址加到 Base 上?
沿用教材的 14 位虚拟地址模型:高 2 位选段、低 12 位编码段内位置,每个段号对应的虚拟窗口最多 4 KiB。本例按教材栈地址使用 11 作为栈段号;段号只是约定,不能混用另一幅图的编码。
访问堆地址 4200(0x1068)时,高 2 位为 01,低 12 位为 0x068,即 104。因此:
104 小于堆长 2048,访问合法;如果访问 7 KiB,偏移就是 3 KiB,超出堆长。段号负责选哪组寄存器,不能再把段号贡献的那部分地址加进去。
栈则要反着数。访问 15 KiB(0x3C00)时,高位 11 选中栈,低位是 3 KiB;相对 4 KiB 窗口的高端,它实际位于向下 1 KiB 的位置:
这里 Base、Bounds 的单位统一按 KiB 理解。段可以带读、写、执行权限;两个进程也可以把各自的代码段映射到同一物理区域并禁止写入,实现共享。共享只读代码不等于共享全部私有数据。
对应教材 §16.1—16.4(书内第 111—115 页,PDF 第 123—127 页);Youjip 第 16 章 PPT 第 4—11 张。
分段的致命缺陷:外部碎片(External Fragmentation)
分段解决了堆栈空洞的问题,但带来了新的物理内存管理危机:每个段的大小各不相同(变长分配)。
- 随着进程的创建与销毁、段的扩展与回收,物理内存被切割得像拼图一样零碎。
- 外部碎片:系统中虽然有足够的空闲内存总量,但因为它们分散在互不相邻的细小空隙中,无法满足任何一个连续大段的申请请求。
- 可以通过内存紧凑(Compaction)搬移已有段、更新基址,把空闲区域聚到一起,但要暂停相关进程并复制数据,成本较高。它与下文“不搬动已分配内容、只合并相邻空闲块”的合并操作不同。
2.3 空闲空间管理:有空闲内存,为什么还分配失败?
这就是教材第 17 章要接着解决的问题。最佳匹配在这里:它负责从大小不同的空闲块中选一块分配。 后面的 OPT 则负责淘汰已驻留的页面,两者回答的问题不同。
先明确管理的是哪一层:分段系统可以在物理地址空间里寻找连续区域;malloc 分配器则在进程的虚拟地址空间里管理堆块。对 malloc 来说,两块虚拟地址连续才有可能合并,背后的物理页框不必连续。下面没有特别说明时,讨论教材第 17 章的用户态堆模型。
外部碎片与内部碎片,浪费发生在哪里?
| 类型 | 看什么 | 例子 |
|---|---|---|
| 外部碎片 | 已分配块之外,空闲区域被分散开 | 空闲 12 B、24 B,中间隔着仍在使用的块;总共 36 B,却不能分配连续 30 B |
| 内部碎片 | 已经给出的块里面,用不到的部分 | 按 32 B 大小类给请求 25 B 的对象分配空间,多出的 7 B 不能再单独分给别人 |
“空闲总量够”只是必要条件。对一次连续分配,还必须有某一块足够大。内部碎片与头部元数据开销也要分别记账:前者是给出的容量超出有效需求,后者是分配器保存管理信息所需的空间。
分割与合并:先会改空闲列表,再谈选块
暂时忽略头部、对齐与最小块大小。用左闭右开的区间写一个 64 B 的小堆,[0,16) 表示地址 0—15,共 16 B:
地址: 0 16 32 64
[ 空闲16 B ][ 使用16 B ][ 空闲32 B ]
空闲列表:(起点0, 长16) → (起点32, 长32)
如果从最后一块分配 12 B,分配器把 [32,64) 分割(split)成两块:[32,44) 交给调用者,[44,64) 的 20 B 留在空闲列表。不是把整个 32 B 都算成已用,也不是仍把原 32 B 留在列表中。
再看一个独立场景:回到初始状态,释放中间的 [16,32)。此时 [0,16)、[16,32)、[32,64) 三块在地址上连续,可以合并(coalesce)成 [0,64)。没有合并时,列表里仍找不到单块 48 B;合并后就能满足。
链表上相邻,不等于内存地址相邻。 若列表按释放顺序排列,两个相邻节点可能隔着很远;只有前一块的起点加长度恰好等于后一块的起点,才可直接拼接。常见实现按地址组织空闲块,或借助边界标记寻找邻块。
合并没有移动任何仍在使用的对象。普通 C 分配器也不能随意搬动活跃对象来做紧凑:程序可能在许多地方保存了指向对象及其内部的指针,分配器不知道该改哪些引用。
对应教材 §17.1—17.2(书内第 120—123 页,PDF 第 132—135 页);第 17 章英文 PPT 第 2—7 张。64 B 小堆为自设例子。
2.4 最佳匹配等四种策略:到底从哪里开始找,选哪块?
先统一名称:教材中的最优匹配/最小匹配(Best Fit),就是课堂常说的最佳匹配/最佳适应;最差匹配也常叫最坏匹配。名字不同,选块规则相同。
| 策略 | 怎么选 | 简单链表实现的代价与局限 |
|---|---|---|
| 首次匹配 First Fit | 从表头开始,遇到第一块足够大的就选 | 可以提前结束,但前部可能积累小块;结果依赖列表顺序 |
| 下次匹配 Next Fit | 从上次查找结束的位置继续,必要时绕回表头 | 需要保存游标;最坏仍可能检查整个列表 |
| 最佳匹配 Best Fit | 所有足够大的块中,选最小的 | 未排序列表通常要扫描全表;若遇到恰好匹配可提前停止;可能留下难用的小余块 |
| 最差匹配 Worst Fit | 选最大的空闲块 | 简单实现通常扫描全表,希望分割后仍留较大的余块,但也会消耗原本可服务大请求的大块 |
这里说的“首次”,是列表中的首次,只有列表按地址递增排列时才等于“最低地址”。Best Fit 也不是找“绝对差值最小”的块:比请求小的块必须先排除。不同数据结构能改变查找成本,不能把全表扫描当成策略定义本身。
先做一次分配,再看连续请求如何改变结局
空闲列表依次为 10 B、30 B、20 B,申请 15 B。这是教材用来说明选块差异的小例子,忽略头部和对齐:
- 首次匹配跳过 10 B,选 30 B,剩余列表为
10 → 15 → 20。 - 最佳匹配选 20 B,剩余列表为
10 → 30 → 5。 - 最差匹配选 30 B,结果恰好与本次首次匹配相同,但查找规则不同。
- 下次匹配必须知道游标在哪里;若从 20 B 那块开始,就选 20 B。题目没给游标,不能凭名字猜出唯一答案。
注意剩余的 5 B 是空闲块,尚未被分配,因此不能说“Best Fit 产生了 5 B 内部碎片”。它很小,后续可能难以利用;真实实现还要判断它是否够保存空闲块元数据,否则可能干脆把余量一并交出去。
再做一个自设的连续分配题。初始空闲块按地址排序为 A=100、B=500、C=200、D=300、E=600 KiB,块之间均隔着已用内存。请求依次为 212、417、112、426 KiB;每种策略从同一初态独立开始,均从选中块的低地址端分割,余块留在原位置,不考虑头部和对齐,中途不释放。
| 请求 | 首次匹配 | 最佳匹配 | 最差匹配 |
|---|---|---|---|
| 212 | B:500 → 288 | D:300 → 88 | E:600 → 388 |
| 417 | E:600 → 183 | B:500 → 83 | B:500 → 83 |
| 112 | B 的余块:288 → 176 | C:200 → 88 | E 的余块:388 → 276 |
| 426 | 失败,最大余块 300 | E:600 → 174 | 失败,最大余块 300 |
最佳匹配在这组请求中保住了 600 KiB 大块,所以最后成功。它不保证对所有请求序列最好。首次匹配失败时,空闲总量还有 KiB,却没有单块达到 426 KiB,这才是外部碎片导致失败的直观例子。
手算时每分配一次,就更新剩余列表;不要让后一请求重新对着初始列表选块。大小相同的候选块如何打破平局、Next Fit 游标如何移动,都应按题目约定。
对应教材 §17.3(书内第 127—128 页,PDF 第 139—140 页);第 17 章英文 PPT 第 17—19 张。
2.5 free(p) 没传长度,分配器怎么知道收回多少?
教材的一个简单做法是:在返回给用户的有效载荷前,放一个头部(header,译本称头块),记录大小等信息。用户拿到的指针越过头部,指向真正可写的区域:
整个分配块:[ 头部 H 字节 ][ 用户可用区及可能的填充 ]
↑ 块起点 ↑ malloc 返回的 p
在“固定头部紧挨有效载荷”的模型中,free 按字节退回 H 字节就能找到头部,读出整块大小,再把它加入空闲列表。实现方式不止一种,不应把这个布局当成所有 malloc 的固定格式。
对齐例题。 自设模型:块起点已按 8 B 对齐,头部长 8 B,整个块长度向上取整到 8 B 的倍数。请求 13 B,需要:
其中 13 B 是用户需求,8 B 是头部,3 B 是对齐填充。选块时实际要找至少 24 B,不能只找 13 B。如果选中的余量太小,连空闲节点所需字段都放不下,分配器可以不分割,把整块交出去。
分配器自己的空闲列表也要占空间。若它为了建立每个节点又调用自身的 malloc,就会陷入循环依赖;一种做法是把大小和链表指针直接写在空闲块内部。同一块空闲时可存链表链接,分配后这部分空间才转为用户数据或其他元数据。
下面只表达基本机制,不是可直接替换系统分配器的实现:
allocate(n):
need = 按布局、对齐和最小块大小计算实际需求(n)
block = 按所选策略查找足够大的空闲块
若找不到:尝试向 OS 扩展管理区域;仍失败则返回 NULL
若余量足以成为合法空闲块:分割并更新空闲列表
否则:取走整块
写入分配元数据,返回有效载荷指针
release(p):
若 p 为 NULL:直接返回
根据分配器布局找到该块的大小信息
把块标记为空闲,按策略立即合并或延迟合并相邻空闲块
更新空闲列表
release 的前提是 p 为尚未释放的合法分配指针。free(p+1)、重复释放、释放栈上的地址都破坏这个前提;不是“分配器会替我查到正确的块”。释放后也不能继续使用其他指向这块区域的旧指针。
对应教材 §17.2(书内第 122—126 页,PDF 第 134—138 页);第 17 章英文 PPT 第 5—16 张。
2.6 不只扫描一个链表:大小分类、slab 与伙伴系统
分离空闲列表(segregated lists)把不同大小的空闲块分组。请求 24 B,可以优先去合适的大小类,而不是把所有小块、大块都扫描一遍。收益是减少查找;代价是大小类取整可能浪费空间,而且某类空闲很多,另一类仍可能短缺。
教材介绍的 slab 分配器更关注反复创建的同类对象,例如锁、文件系统 inode。一个对象缓存用若干 slab 存放多个同类型对象,空闲时复用对象槽位;缓存不足再向更通用的分配层申请空间,空 slab 可以归还。预先保留可复用的初始化状态,还能减少反复构造与销毁的开销。不要把对象缓存误认为硬件 CPU Cache。
二分伙伴系统(binary buddy)则限制块大小为 2 的幂。设有一块 64 KiB 空闲区域,申请 7 KiB,忽略元数据:
64 KiB
分为 32 + 32
再分左侧 32:16 + 16
再分左侧 16:8 + 8
取一个 8 KiB 块满足 7 KiB 请求
剩余空闲块:8 KiB、16 KiB、32 KiB
分配块内部未用:1 KiB
释放这块 8 KiB 时,若它的伙伴 8 KiB 也空闲,就合为 16 KiB;再看这一层的 16 KiB 伙伴,逐层向上合并。同样大小且相邻还不够,必须是同一个父块二分出来的伙伴。 例如以区域起点为 0,[8,16) 和 [16,24) 虽然各 8 KiB 且相邻,却来自不同的 16 KiB 父块,不能直接合成一个合法伙伴块。
以相对区域起点的字节偏移 x 表示块位置,块大小为 ,对应伙伴的起点可按 找到。它让定位伙伴和合并很直接,但 2 的幂取整带来的内部碎片不会因此消失。
这几种机制可以分层配合,不是整台机器只能四选一。分页负责把虚拟页映射到物理页框,堆分配器负责在虚拟空间中切出对象。 因此,学习分页以后,第 17 章的 Best Fit、分割和合并并没有过时。
本节自测: ① 空闲块 18、40、26 B,请求 24 B,Best Fit 选谁?② 两个连续 16 B 空闲块合并时,需要搬动用户对象吗?③ 伙伴系统为 9 KiB 请求分配多少,内部浪费多少?④ free 后 RSS 为什么可能不下降?
核对空闲空间管理的理解
① 选 26 B,忽略头部和对齐时剩 2 B;真实分配器还要判断能否分割。② 不需要,只合并相邻空闲区的管理记录。③ 分配 16 KiB,未用 7 KiB。④ 空闲块可以先留在分配器中供下次使用,页也可能还装着其他对象;归还给分配器不等于立即解除映射或回收物理页。
对应教材 §17.4—17.5(书内第 128—130 页,PDF 第 140—142 页);第 17 章英文 PPT 第 20—23 张。
三、现代体系的核心:分页机制(Paging)⭐
分段必须给每个段找到连续物理空间。分页换了一个粒度:把虚拟空间和物理内存切成相同大小的页与页框,用映射把不相邻的页框连接成进程看到的连续地址。
3.1 核心思想:固定大小切片
- 虚拟页(Virtual Page, VP):将进程的虚拟地址空间切分成固定大小的单元(最经典的大小是 4 KiB)。
- 物理页框(Page Frame / Physical Frame):将物理内存切分成同样大小的固定单元,其编号称为 PFN(Page Frame Number)。
- 优势:
- 基本页分配不要求多个页框连续:在教材的等大页框模型中,申请 3 页,只需找到 3 个可用页框,不必找到连续的 3 页。若任务额外要求连续物理页或大页,仍可能受物理碎片影响。
- 代价转向内部碎片与映射元数据:按页向上取整可能留出未用尾部,页表自身也占空间。尾部需求近似均匀分布时,“平均浪费约半页”才是合理估算,并非每个进程或每个
malloc都固定浪费半页。
例如一个独立映射区域要放 10 KiB 数据、按 4 KiB 页取整,需 3 页共 12 KiB,尾部未用 2 KiB。若改用 16 KiB 页,只需一个映射项,但尾部未用 6 KiB。页越大、页表越小与内部空间利用率之间存在折中。
3.2 虚拟地址切分与快速拼接模型 ⭐
虚拟地址在逻辑上被划分为两部分:
- 虚拟页号(Virtual Page Number, VPN):用于索引页表中的条目。
- 页内偏移量(Offset):指示页内的具体字节位置。
高 20 位 [31 : 12]
低 12 位 [11 : 0]
通过 VPN 查页表得到物理页框号(PFN)。因为物理页框与虚拟页大小完全一致,页内偏移 Offset 原样照抄,无需经过任何加法器计算: $$PA = (\text{PFN} \ll 12) \mid \text{Offset}$$
经典手算技巧:16 进制快速拆分
假设页大小为 ,虚拟地址为 0x12345:
- 低 12 位对应 16 进制后 3 位(每位 16 进制占 4 bit):
0x345即为 Offset。 - 剩余高位:
0x12即为 VPN。 - 若页表记录 VPN
0x12映射到 PFN0xAB,则物理地址为0xAB345。
别跳过中间一步:页表项本身也有地址
“查页表”不是一个不用访存的黑盒。在线性页表模型中,硬件先计算 PTE 放在哪里,读到它后才知道数据页框在哪里。接着上例,另设页表物理基址 PTBR=0x8000,每项 8 B,页表页已驻留,无 TLB、无缓存:
访问 0x8090 读出的 PTE 假设合法、允许本次访问且记录 PFN=0xAB,才继续访问数据的物理地址 0xAB345。0x8090 是“地址记录存在哪里”,0xAB345 是“要读的数据存在哪里”。 这次数据读取在简化模型里需要两次物理访存。
也可以先用小数值理解:页大小 16 B,虚拟地址 45 满足 ,所以 VPN=2、Offset=13。若第 2 项 PTE 记录 PFN=5,则 。只有页号发生替换,页内第几个字节没有变化。
对应教材 §18.1—18.4(书内第 133—137 页,PDF 第 145—149 页);第 18 章 PPT 第 4—13 张。数值为自设。
3.3 页表项(PTE, Page Table Entry)核心位解析 ⭐
最简单的线性页表是以 VPN 为下标的数组,每个条目即为页表项(PTE);后面会把这个大数组组织成多级结构。下表先采用教材的抽象字段,真实硬件不一定用完全相同的名字或独立比特表达它们。
| 标志位(Bit) | 中文全称 | 核心功能与技术意图 |
|---|---|---|
| Valid Bit | 有效位 / 合法位 | 教材中标识该页是否属于合法地址空间。非法访问触发异常,由内核处理;Linux 上可能向进程发送 SIGSEGV,信号不是硬件直接发送的。 |
| Present Bit | 存在位 / 驻留位 | 标识该页当前是否真正在物理内存(RAM)中。置 0 表示该页被交换到磁盘或尚未分配物理页框,访问时触发缺页异常(Page Fault)。 |
| Read / Write (R/W) | 读写权限位 | 0 表示只读(如代码段),1 表示可读写(如堆、栈)。写只读页触发写保护异常。 |
| Dirty Bit (D) | 脏位 / 修改位 | 记录页面被修改。回收仍需保留内容的页时,必须确保最新内容有后备副本;干净且已有有效后备副本的页可免写回。 |
| Accessed / Reference (A) | 访问位 / 引用位 | 页面发生读或写时由硬件自动置 1。用于操作系统统计活跃度,是时钟页面置换算法(Clock Algorithm)的决策基石。 |
三个问题要分开问:这个地址合法吗?本次读写被允许吗?页面现在驻留吗? 例如一个合法的匿名区域可能还没建立物理页,内核能按需补上;真正未映射的地址则不能“随便给它分一页”就放行。实际系统还会用 VMA 等内核结构记录区域的合法性与语义,不能单凭某个 PTE 位判断全部情况。
3.4 分页机制的两大核心危机
分页解决了基本页映射的物理连续性问题,又引入两个主要代价:
- 时间危机:访存延迟翻倍(太慢):
- 在没有 TLB、缓存,且线性页表已经驻留的简化模型下,每次访问先读一次 PTE,再读目标数据,物理访存次数从 1 次变成 2 次。这不等于真实程序运行时间必然翻倍。
- 空间危机:页表体积过大(太占内存):
- 在 32 位系统下,页大小 4KB,整个空间包含 个虚拟页。
- 若每个 PTE 占用 4 字节,每个进程的单级线性页表需要占用 的连续物理内存!
- 若 100 个进程都保留这样的完整线性页表,仅页表就占 400 MiB;实际系统会用多级等结构缓解问题。计算时区分虚拟地址有效位数、PTE 大小与机器的“64 位”称呼。
四、分页危机的化解:TLB 快表与多级页表 ⭐
操作系统与体系结构设计者通过引入两个核心技术,分别化解了分页的时间与空间危机:
- 解决时间危机 TLB 快表(加速地址转换)
- 解决空间危机 多级页表(按需分配稀疏空间)
4.1 速度优化:快表 TLB(Translation Lookaside Buffer)
TLB 是集成在 CPU MMU 芯片内部的超高速硬件缓存,专门用来缓存近期的 VPN -> PFN 映射关系。
为什么 TLB 能够极速挽救性能?
根据计算机体系结构的局部性原理(Locality):
- 时间局部性:近期访问过的指令或数据,很快会再次被访问。
- 空间局部性:访问了某个地址,其邻近地址大概率也会紧接着被访问。
- 假设数组按 4 KiB 页对齐、整数 4 B、只统计数组访问,且该映射不被挤出 TLB:顺序访问同一页的 1024 个整数,第一次未命中,余下 1023 次可以命中,命中率约 99.9%。这是带条件的例子;实际延迟与 TLB 层级、相联度和处理器实现有关。
跟着教材逐次看:十个整数为什么只有七次命中?
采用 §19.2 的小模型:页大小 16 B,数组起始虚拟地址为十进制 100,10 个元素,每个 4 B。初始 TLB 为空,容量够且无冲突淘汰,页均已驻留,只统计数组的数据读取。
| 数组元素 | 虚拟地址 | VPN | 页内偏移 | TLB 结果 |
|---|---|---|---|---|
a[0] | 100 | 6 | 4 | 未命中,装入 VPN 6 的映射 |
a[1] | 104 | 6 | 8 | 命中 |
a[2] | 108 | 6 | 12 | 命中 |
a[3] | 112 | 7 | 0 | 未命中,装入 VPN 7 的映射 |
a[4] | 116 | 7 | 4 | 命中 |
a[5] | 120 | 7 | 8 | 命中 |
a[6] | 124 | 7 | 12 | 命中 |
a[7] | 128 | 8 | 0 | 未命中,装入 VPN 8 的映射 |
a[8] | 132 | 8 | 4 | 命中 |
a[9] | 136 | 8 | 8 | 命中 |
因此第一次扫描命中率为 。第一次读 a[1] 也能命中,因为缓存的是整页的地址映射,不是 a[0] 那一个整数。若马上再扫一遍且映射仍在,10 次都可命中。
TLB 的有效位只表示“这条缓存记录能不能用”,不能和 PTE 表示地址合法性的 Valid 混为一谈。TLB 一开始全部无效,不代表进程的全部地址都非法。
反过来,每次跨一页、循环扫描比 TLB 容量更多的页,就可能不断挤掉即将再用的映射。粗略的 TLB 覆盖范围(reach)= 条目数 × 每条覆盖的页大小;例如 64 项、每页 4 KiB,最多覆盖 256 KiB 的页映射,冲突和多种页大小还会改变实际效果。这里可联系计算机系统:存储系统中的相联缓存与局部性,但 TLB 缓存映射,数据 Cache 缓存数据内容。
对应教材 §19.2—19.5(书内第 143—148 页,PDF 第 155—160 页);第 19 章 PPT 第 5—14 张;另参照 Andrea 的 TLB 课件。
- Step 1:提取 VPN 并并发比对 TLB CPU 产生虚拟地址,MMU 提取 VPN 并查询 TLB。教材常用全相联模型,真实实现也可以采用组相联等组织。
- Step 2A:TLB 命中(TLB Hit)—— 使用缓存的映射 直接提取 PFN,与 Offset 拼接组成物理地址 PA,完成权限检查后直接访问物理内存。
-
Step 2B:TLB 未命中(TLB Miss)—— 穿透遍历页表
未在快表中找到记录:
- 硬件管理 TLB(如 x86):CPU 内部硬件 Page Table Walker 自动遍历内存中的页表。
- 软件管理 TLB(教材举例 MIPS R10k):抛出 TLB Miss 陷阱,内核查找页表并用特权指令填入映射。不能仅凭 RISC/CISC 标签判定某个架构采用哪种方式。 - Step 3:装载后指令重试 填入地址映射和权限等信息后,继续或重试原访问;在映射仍有效且未被淘汰的模型中,这次可命中。
核心辨析:TLB Miss Page Fault ⭐
| 比较维度 | TLB Miss(快表未命中) | Page Fault(缺页异常) |
|---|---|---|
| 本质属性 | 没在地址转换缓存中找到可用映射 | 当前访问无法直接完成,需要内核处理的分页异常 |
| 目标页在 RAM 中吗? | 可能在,查页表后才能知道 | 可能不在;也可能已经在,例如写 COW 页触发保护异常 |
| 需要磁盘 I/O 吗? | 单纯补充驻留页映射不需要 | 不一定;换入可能需要 I/O,按需置零、COW 等可在内存中处理 |
| 处理开销 | 与页表层级及缓存命中情况有关 | 差异很大;涉及磁盘 I/O 的路径通常远贵于只修改映射或分配内存的路径 |
教材第 21 章主要研究“合法页已换出”的情况;现代系统的 page fault 范围更广。后面 §5.1 会把这些路径分开。Linux 内核的页表文档也明确列出了按需分配、COW 与换出后的访问等原因。
进程上下文切换时的 TLB 困境与 ASID 方案
不同进程各自拥有独立的地址空间,其相同数值的 VPN 映射的物理页框完全不同。
- 若进程切换时不作处理,进程 B 就会错误命中进程 A 刚才留在 TLB 中的页框。
- 解法一:清空 TLB(Flush):每次切换把 TLB 全部清空。代价是新进程刚开始运行时面临剧烈的 TLB Miss(冷启动)。
- 解法二:地址空间标识符(ASID):硬件在 TLB 条目中增加地址空间标识,比对
VPN与ASID,允许不同进程的映射共存,减少切换时的清空。ASID 不是永不重复的 PID;标识复用、映射或权限改变时,仍需按硬件协议使旧记录失效,多核还要处理其他 CPU 缓存的相关映射。
4.2 空间优化:多级页表(Multi-level Page Tables)
线性页表之所以浪费内存,是因为它逼迫操作系统为一个可能绝大部分都是空洞的虚拟地址空间维护全部映射项。
教材第 20 章先尝试两条路,再引出多级页表。增大页可以减少 PTE 数量,但内部碎片可能增加;分段加分页为代码、堆、栈分别维护页表,让段的 Base 指向该段页表,能略去段间空洞,但段内稀疏区域仍浪费页表,变长页表自身也不好分配。多级页表进一步把页表本身切成固定大小的页,再用上一级记录它们的位置。
技术直觉:正如一本厚字典不会为没有单词的字母组合留出几百页白纸,多级页表引入页目录(Page Directory):
只有当某个大范围内的虚拟地址真正被分配使用时,才去动态分配该范围对应的二级页表。
高 10 位 [31 : 22] (1024 项)
中间 10 位 [21 : 12] (1024 项)
低 12 位 [11 : 0] (4096 字节)
一个页目录项(PDE)负责管理 4 MiB 的虚拟地址范围。如果这 4 MiB 区域完全没有被程序使用,只需将该 PDE 的 Valid 位置 0。系统完全不需要为这片区域分配二级页表所在的物理页!一个仅包含几个有效段的稀疏程序,页表空间占用可从 4MB 骤降至几 KB。
10 + 10 + 12 是怎么推出来的?
上图采用 32 位虚拟地址、4 KiB 页、PTE 与 PDE 都为 4 B、每张下级表恰好占一页的模型:
- 一页有 个字节,Offset 占 12 位。
- 一张二级页表放 项,选择 PTE 需要 10 位。
- VPN 总共 位,剩下 10 位选择页目录项。
- 一张二级页表能映射 的虚拟地址范围。
PDE 指向存放下一层表的页框,PTE 指向最终数据所在的页框。不要看到两者都有 PFN,就把目录项里的 PFN 当成数据页框。
一个很小的地址空间,怎样从 1024 B 页表缩到 192 B?
按教材 §20.3 的模型:虚拟地址空间 16 KiB,页大小 64 B,PTE 与 PDE 都是 4 B。只有 VPN 0、1、4、5、254、255 有映射。
一页能放 项,完整线性页表占 16 页。把 VPN 每 16 个分成一组:0、1、4、5 都落在第 0 组,254、255 落在第 15 组,其余组没有映射,因此只需两张下级表。
| 必须保留的结构 | 覆盖范围 | 占用 |
|---|---|---|
| 页目录 | 记录 16 组下级表的位置或不存在 | 64 B |
| 下级表 0 | VPN 0—15 | 64 B |
| 下级表 15 | VPN 240—255 | 64 B |
| 合计 | 不含代码、堆、栈的数据页 | 192 B |
它不是“6 个有效页,所以只要 6 个 PTE 的空间”。分配单位是页表页,同一张下级表里没用到的项仍占位置;顶级目录也要保留。映射越分散,可能要建立的下级表越多。若这 256 个虚拟页全部有映射,则两级结构需要 B,反而比线性页表多一个目录,省空间的关键是稀疏性。
对应教材 §20.1—20.3(书内第 154—162 页,PDF 第 166—174 页);第 20 章 PPT 第 2—23 张。
经典手算考题:两级页表寻址推导 ⭐
题目条件:
32 位虚拟地址,页大小 4 KiB,PTE 与 PDE 均为 4 B,每张表占一页,索引从 0 开始。
虚拟地址为VA = 0x00403004,推导 PDI、PTI 与 Offset。
- 写出二进制形式:
0x00403004=0000 0000 0100 0000 0011 0000 0000 0100 - 按位切分 [31] | [21] | [11]:
- PDI(高 10 位):
0000 0000 011(PDE[1],从 0 编号的第二项) - PTI(中 10 位):
00 0000 00113(PTE[3],从 0 编号的第四项) - Offset(低 12 位):
0000 0000 01004(页内偏移 4 字节)
- PDI(高 10 位):
- 寻址流转:
- 根据页目录基址寄存器(如 x86 的
CR3)找到页目录 取 PDE[1] 找到二级页表物理基址 取 PTE[3] 得到 PFN 拼接 Offset0x004得到最终物理地址。
- 根据页目录基址寄存器(如 x86 的
再补完整访存地址。 假设页目录物理基址为 0x1000,PDE[1] 记录下级表 PFN=0x2,该下级表的 PTE[3] 记录数据 PFN=0x9,权限允许、相关页全部驻留:
| 顺序 | 要读什么 | 物理地址怎么求 |
|---|---|---|
| 1 | 页目录项 PDE[1] | 0x1000 + 1 × 4 = 0x1004 |
| 2 | 下级页表项 PTE[3] | 0x2000 + 3 × 4 = 0x200C |
| 3 | 目标数据 | 0x9000 + 0x004 = 0x9004 |
所以在没有 TLB 命中和其他缓存帮助时,两次读表,加一次读数据,共三次物理访存。命中 TLB 时可直接取得最终映射,不必每次走目录。一般的 k 级页表在这个串行模型中需要 k 次读表,再加数据访问;实际处理器的页表遍历缓存会改变成本。
为什么还有反向页表?
线性与多级页表主要围绕“某进程的某个虚拟页映射到哪里”组织。教材 §20.4 的反向页表(inverted page table)改为围绕物理页框建记录:每个物理页框对应一项,记录所属地址空间与虚拟页号。表的规模由物理页框数量决定,减少对巨大虚拟空间的依赖。
代价是查找方向不顺了:CPU 给的是进程标识和 VPN,不能直接把 VPN 当数组下标,通常要借助散列等索引;共享页等情况还需要额外处理。它也不同于“把 VPN 与 Offset 反过来写”。教材 §20.5 还讨论部分页表能否换出,但具体架构能允许什么结构换出,不能脱离页表遍历机制一概而论。
对应教材 §20.4—20.5(书内第 162—163 页,PDF 第 174—175 页);第 20 章 PPT 第 24 张。
五、超越物理内存:换页机制与置换策略 ⭐
有了分页、TLB 和多级页表,还可以让暂时不用的页留在后备存储中,只让当前需要的页驻留 RAM。交换空间(Swap Space)提供一种后备位置;文件映射页也可以从原文件重新读取。虚拟地址空间、可提交内存和物理内存都是有限的,换页的价值是让活跃部分共享有限 RAM,而不是让内存真的无限。
5.1 缺页异常(Page Fault)处理全流程 ⭐
先看教材第 21 章的主场景:访问合法、权限允许的页,但该页已换出,当前 PTE 中 Present Bit = 0。下面七步演示需要读取后备存储的路径;不是所有 page fault 都会执行磁盘 I/O。
- Step 1:硬件触发缺页陷阱(Trap to Kernel) CPU MMU 遇到 Present Bit=0,停止执行当前指令,将出错虚拟地址保存至专用寄存器(如 CR2),陷入内核。
-
Step 2:内核校验合法性
检查区域是否合法、本次操作是否允许。以 Linux 为例,可结合 VMA 判断;非法用户访问通常发送
SIGSEGV,合法但尚未驻留则继续处理。 - Step 3:寻找空闲物理页框 从空闲链表取页框。若物理内存已满,调用页面置换算法选出一个牺牲页(Victim Page)。
- Step 4:换出牺牲页(Evict) 若牺牲页仍需保存且没有最新后备副本,先写回适当的文件或交换空间;干净且已有可恢复副本的页可免写回。回收前还要更新原映射,并让相关旧 TLB 映射失效,避免继续访问被复用的页框。
- Step 5:换入目标页(Swap In) 发起磁盘读 I/O,将目标页数据读入空闲页框中。在此期间当前进程挂起阻塞(Blocked),CPU 调度其他进程运行。
- Step 6:I/O 完成中断并更新元数据 I/O 完成后内核更新 PFN、驻留状态和权限等元数据,按需维护 TLB,并唤醒等待的进程,使其可以重新被调度。
- Step 7:重新执行原始指令(Retry Instruction) 从内核态返回,重新执行刚才那条引发缺页的 load/store 指令。此时页已驻留,指令顺利执行。
“缺页”之后,可能根本不用读磁盘
教材 §21.2 的术语补充已经提醒:page fault 有时也包含非法访问。把“触发分页异常”与“发现原因后如何处理”分开看,就不容易和后面的 COW 矛盾。
| 触发原因 | 内核为什么接管 | 可能的处理 |
|---|---|---|
| 合法匿名区域第一次使用 | 还没建立实际页或映射 | 按需置零并建立映射,一般不必读取磁盘 |
| 写 COW 页 | 页面存在,但临时只读 | 在需要时复制为私有页并恢复写权限 |
| 合法页已经换出 | 内容在交换空间 | 找页框、读取后备内容、更新映射 |
| 文件映射尚未建立 PTE | 所需数据可能已在内存页缓存 | 已在内存时可直接建立映射,否则可能读文件 |
| 真正未映射或不允许的操作 | 本次访问违反地址或权限规则 | 报告错误,不能任意分配一页掩盖问题 |
Linux 统计中的 minor fault 不需要从磁盘加载页面,major fault 则需要加载。因而看到缺页计数增长,不能直接认定发生了同样次数的磁盘换入。参见 Linux /proc/PID/stat 的 minflt/majflt 定义。
一个完整小情境。 load 访问的页已经换出,TLB 也没有映射:先 TLB Miss,查页表后发现不驻留,才进入缺页处理。进程等待 I/O 时从运行态转为阻塞态;I/O 完成后成为就绪态;再次得到 CPU 时重试原来的 load。不能跳到下一条指令,否则那次读操作就丢了。这里可以联系进程与 CPU 调度中的状态转换。
对应教材 §21.2—21.5(书内第 166—169 页,PDF 第 178—181 页);第 21 章 PPT 第 4—11 张。Linux 实现边界另参照前面的内核文档。
换页时机:高低水位线机制
教材用低水位线 LW、高水位线 HW解释后台页回收:当空闲页少于 LW,唤醒后台线程,回收到 HW 左右再休眠。这样能为后续请求保留余量,避免每次分配都等待同步回收。现实内核的水位还涉及内存区域、分配类别和直接回收等细节,这里先记教材的机制,不把两条线当成全部实现。
$ vm_stat
Mach Virtual Memory Statistics: (page size of 16384 bytes)
Pages free: 6740.
Pages active: 188905.
Pages wired down: 182777.
"Translation faults": 209154721.
Pages copy-on-write: 13430625.
Pages zero filled: 380597063.
-
page size of 16384 bytes:这份 macOS 采样报告页大小为 16 KiB;不要拿教材例题的 4 KiB 去乘本次页数,也不要从这份采样推出所有 Unix 的页大小。-
Pages wired down:线连/锁定驻留的页,在被 wired 的期间不可换出;这是系统管理状态,不应理解成通用的“硬件把页锁住”。-
Pages copy-on-write:写时复制触发分裂的累计次数。-
Pages zero filled:按需分配零填充页的次数。
5.2 页面置换策略(Replacement Policies)⭐
当物理内存满时,应该淘汰哪一个页面?
1. OPT(理论最优算法 / MIN)
- 规则:淘汰在未来最长时间内不会被访问的页面。
- 评价:理论缺页率最低。但操作系统无法预知未来指令流,工程上不可实现,仅作为评估基准。
2. FIFO(先进先出算法)与 Belady 异常
- 规则:淘汰最早进入物理内存的页面。
- 缺陷:Belady 异常(Belady's Anomaly):
- 增加物理页框数,缺页次数反而增加!
- 根本原因:FIFO 只关心进入时间,忽视了页面的重要性和访问频率。FIFO 不具备栈式算法(Stack Algorithm)的包含性质。
3. LRU(最近最少使用算法)
- 规则:利用历史预测未来——淘汰过去最长时间未被访问过的页面。
- 性质:具备栈性质,绝不会出现 Belady 异常。
- 代价:精确维护 LRU 需要硬件在每次访存时更新时间戳或调整链表指针,硬件代价过高。
4. 时钟算法(Clock Algorithm / 第二次机会算法)
- 规则:用 1 个硬件 Use Bit(访问位) 环形扫描近似 LRU:
- 页面被读写时,硬件自动将该页的
Use Bit置为 1。 - 需要置换时,指针环形扫描:
- 若当前页
Use Bit == 1:清为 0,给它一次机会,指针前移。 - 若当前页
Use Bit == 0:自最近一次清零后未观察到新的访问,选定淘汰。不必恰好已经走完一整圈。
- 若当前页
- 页面被读写时,硬件自动将该页的
动作:将 Use Bit 清为 0,时钟指针前移。
动作:选定为牺牲页淘汰,腾出页框!
手算对比追踪表(3 个物理页框)⭐
访问序列:1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5:
| 算法 | 缺页状态追踪 | 总缺页次数 |
|---|---|---|
| FIFO | 1(缺), 2(缺), 3(缺), 4(缺), 1(缺), 2(缺), 5(缺), 1(中), 2(中), 3(缺), 4(缺), 5(中) | 9 次 |
| LRU | 1(缺), 2(缺), 3(缺), 4(缺), 1(缺), 2(缺), 5(缺), 1(中), 2(中), 3(缺), 4(缺), 5(缺) | 10 次 |
这里初始页框为空,每个算法独立开始,最初装入也算缺页。FIFO 在命中时不改变入队顺序;LRU 在命中时要把该页记为最近使用,手算很容易漏掉这一步。
用同一个序列把 FIFO 页框增加到 4 个,前六次访问为 1缺、2缺、3缺、4缺、1中、2中;之后的 5、1、2、3、4、5 全部缺页,共 10 次。它比 3 页框的 9 次还多,这才是一组完整的 Belady 异常对照。3 页框时 LRU 在本序列比 FIFO 多一次,也说明“利用历史”不等于对所有有限序列都胜出。
Clock 小例题:时钟指针究竟怎么走?
四个页框按 A、B、C、D 环形排列,引用位分别为 1、0、1、1,指针起初在 A。现在 E 缺页,题目约定装入后的引用位为 1,替换后指针移到下一页;扫描期间没有其他访问。
| 动作 | 看到的引用位 | 处理 |
|---|---|---|
| 检查 A | 1 | 清为 0,指针移到 B |
| 检查 B | 0 | 淘汰 B,装入 E,并置引用位 1 |
| 本次结束 | A=0、E=1、C=1、D=1 | 指针停在 C,下一次从这里继续 |
它没有计算“谁上次访问时间最早”,只是用有限的信息给近期访问过的页第二次机会。若所有引用位都是 1,就先清一圈,再选择遇到的 0;并发访问会让某些位重新变成 1。
教材 §22.9 再加入脏位考虑:在都不活跃的候选页中,有有效后备副本的干净页回收成本较低;脏页若仍需保留内容,还要安排写回。降低缺页次数与降低一次替换的 I/O 成本是相关但不相同的目标。
为什么教材还要比较“工作负载”?
策略好坏与访问顺序有关。自设一个小模型:只有 3 个页框,不断循环访问 A、B、C、D。FIFO 与 LRU 在这里都会持续缺页:刚淘汰 A,下一轮就又需要 A;装入 A 时再淘汰 B,又马上要 B。具有局部性是经验假设,循环范围恰好大于容量时,这种假设会失效。
教材还讨论随机替换、偏向少数热点页的工作负载,以及预取和批量写回。预取是预测将来要用的页,猜错会占用页框并造成额外 I/O;批量写回试图摊薄 I/O 开销,也要和保持空闲页的需求配合。
对应教材 §22.2—22.10(书内第 173—183 页,PDF 第 185—195 页);第 22 章 PPT 中的策略追踪、工作负载和 Clock 讨论。A—E 的小例子为自设。
系统颠簸(Thrashing)与工作集模型
- 工作集(Working Set):进程在一段时间窗口内频繁访问的页面集合。
- 活跃工作集需求超过可供其使用的物理页框时,系统可能反复换出马上又要用的页,主要时间花在等待换页,有效工作大幅减少,这就是颠簸(Thrashing)。共享页不能简单重复相加,“超过容量”也不是保证立即颠簸的机械判定。
例如两个进程各有 80 个不同的活跃页,总共只有 100 个可用页框。全局替换可以从其他进程拿页,但可能相互挤压;局部替换限制从本进程驻留集中选,隔离了影响,却可能让一个配额过小的进程持续缺页。限制并发活跃进程、调整配额或访问模式、增加内存,都可能缓解;单纯加 CPU 核心通常解决不了页框短缺。
六、现代操作系统机制与用户态 API
6.1 写时复制(Copy-on-Write, COW)
如果 fork() 立即复制父进程的全部数据页,即使子进程马上 exec() 丢弃这些内容,也先白白复制一次。COW 把复制推迟到确实有人写入时。
- 内核为子进程建立地址空间与页表等元数据,适用 COW 的私有数据页暂时共享物理页。
- 父子两边相关映射都暂时撤销硬件写权限,并记录这是可通过 COW 处理的私有映射,必要时使旧 TLB 权限失效。显式共享映射另有语义,不能把所有可写页一概改成 COW。
- 双方只读时不需要复制。某一方写入,触发保护异常;内核确认属于 COW 后,在仍有共享者时分配并复制一页,更新写入方的映射及权限,然后重试写操作。
- 如果已不存在其他共享者,有的实现可直接恢复写权限,省去复制。
fork()仍有元数据等成本,只是避免了立即复制全部数据。
追踪一个变量。 父进程私有页中的 x 原来为 7,父子的同一虚拟地址暂时都映射到 PFN 20。子进程执行 x=9,假设触发复制到 PFN 35:父进程仍从 PFN 20 读到 7,子进程从 PFN 35 读到 9。虚拟地址可以相同,页表让它们指向不同的物理副本。
可以和 CSAPP Shell Lab 中的 fork → exec 联系:子进程通常很快换成新程序,延迟复制能省掉不少随后就不用的数据页。
6.2 惰性调页与零页(Demand Paging & Zero-Fill)
教材 §23.5 的思路是:为新区域先记录合法性与按需置零语义,等访问时再提供清零后的物理页,避免给从来没用过的地址做工作。部分系统还会在读取新匿名区域时共享只读零页,真正写入时才建立私有物理页;具体路径依系统与映射方式而异。
这不等于“malloc 返回的内容一定是零”。malloc 可能复用分配器已有的块,不保证初始化;calloc 才保证分配区域的各字节为零。操作系统防止新获得的匿名内存泄漏其他进程数据,与 C 库对复用块的接口承诺,是两个层次的问题。
6.3 用户态内存分配器与内核系统调用
| 层次 | 核心接口 | 职责与本质 |
|---|---|---|
| 用户态库 | malloc() / free() | 管理进程中的对象块,可以使用空闲列表、大小类等结构;许多操作直接在已有区域内完成。 |
| 堆边界接口 | brk() / sbrk() | 调整传统数据段的 break;在 Linux 上,sbrk 是基于 brk 的库接口。不是每次 malloc 都调用它。 |
| 内核系统调用 | mmap() / munmap() | 在自由映射区开辟/释放匿名页或文件映射,常用于大块内存分配。 |
[!WARNING] 核心考点:页级保护 C 对象边界检查
普通分页 MMU 检查映射与页权限,并不知道malloc(32)的对象边界。访问超出这 32 字节、但仍落在允许访问的页内,单靠页保护未必能立即发现;跨页也未必出错,下一页可能同样可访问。越界始终属于程序错误,可能破坏邻接对象或被额外检测工具发现,不能以“没崩溃”判断合法。
把一次 malloc 到 free 串起来
malloc(n)
→ 分配器先查自己管理的空闲块
→ 有合适块:选块、必要时分割、返回虚拟地址
→ 没有合适块:视实现向 OS 申请更多地址空间,再纳入分配器
程序访问返回的区域
→ 地址转换;若需要,触发按需建立映射或调入页面
free(p)
→ 归还给分配器,可能合并、缓存供下次使用
→ 是否归还 OS、何时解除映射,取决于布局和分配器策略
这里的“空闲”有三种:分配器可复用的对象块、进程尚未映射的虚拟地址范围、系统可分配的物理页框。一个对象块空闲了,不表示它所在页里其他对象也都空闲,更不表示 RSS 立即减小。
教材第 14 章还强调几类常见错误:sizeof(p) 得到指针大小而不是所指数组大小;字符串分配要计入结尾的 \0;申请成功不代表已经初始化;free 后旧指针不能再解引用。realloc 也可能搬家,非零新大小下失败会保留原块,因此不能随手用唯一的旧指针覆盖返回值。
对应教材第 14 章、§17.2、§23.5;第 14 章 PPT 第 2—17 张。现代库接口细节参照 Linux malloc 手册。
6.4 教材的完整案例:VAX/VMS 怎样把这些机制组合起来?
第 23 章用一个历史系统检验前面的知识能否连起来,不要求把它当成今天 Linux 的实现图。
| 当时面对的问题 | VAX/VMS 的做法 | 联系前面的机制 |
|---|---|---|
| 32 位地址、512 B 小页导致页表条目多 | 分段与分页结合,为不同进程区域分别管理页表 | 略去区域间空洞,减少页表浪费 |
| 用户页表自身也占物理内存 | 把用户页表放入内核虚拟空间,在压力下允许换出其中部分页 | 数据结构自身也可以分页,但转换过程更复杂,需要硬件支持与 TLB 配合 |
| 缺少硬件引用位 | 用分段 FIFO 与全局第二次机会列表改进替换 | 已从进程驻留集移出,不等于内容立刻离开 RAM;尚未被复用时可以快速取回 |
| 小页导致单次换页 I/O 太碎 | 把多页聚集后写出 | 页是管理粒度,I/O 不必每次只传一页 |
| 分配或 fork 时做了后来用不到的工作 | 按需置零与 COW | 延迟到访问时再做,有时能完全省掉 |
这里“分段 FIFO”的“分段”指替换列表的组织方式,不要与 §2.2 的地址分段混为一谈。看到一种机制时,最好继续追问:它依赖哪些硬件信息,节约了什么,又增加了哪条慢路径?
对应教材第 23 章(书内第 186—191 页,PDF 第 198—203 页)。
七、综合模型:端到端访存全流程与 EAT 计算 ⭐
7.1 一次内存访问的端到端穿透图
flowchart TD
VA["CPU 产生虚拟地址"] --> TLB{"TLB 有可用映射?"}
TLB -->|有| PERM{"权限允许?"}
PERM -->|是| MEM["组成物理地址,访问目标数据"]
PERM -->|否| FAULT["分页或保护异常:内核接管"]
TLB -->|没有| WALK["遍历页表"]
WALK --> READY{"映射存在、已驻留且允许访问?"}
READY -->|是| FILL["补充 TLB,继续或重试访问"]
FILL --> TLB
READY -->|否| FAULT
FAULT --> CHECK{"区域与本次操作是否合法?"}
CHECK -->|否| ERR["报告非法访问,可能发送 SIGSEGV"]
CHECK -->|是| HANDLE["按原因处理:置零、COW、建立映射或从后备存储调入"]
HANDLE --> UPDATE["成功后更新页表与相关 TLB 状态"]
UPDATE --> RETRY["重新执行原访问"]
RETRY --> TLB
本图采用硬件遍历页表的抽象流程,省略资源不足、I/O 失败等错误分支;软件管理 TLB 的架构会先由内核处理 TLB Miss。“页表里暂时没有映射”与“进程没有权利访问这段地址”要由内核结合区域信息区分。
7.2 有效访存时间(EAT / AMAT)综合计算题型 ⭐
模型一:仅考虑 TLB 与多级页表(无缺页)
设 TLB 查找耗时 ,内存单次访问耗时 ,TLB 命中率 ,采用 级页表串行查询:
假设各级页表已驻留、不考虑数据 Cache 与页表遍历缓存,TLB 查询和后续访存不重叠;一次未命中只计一次 TLB 查询,不另加重试查 TLB 的费用。若题目另有约定,应修改路径成本。
经典例题:
设 ,,两级页表()。要使 ,TLB 命中率 最少为多少?解:
模型二:综合考虑缺页异常与磁盘开销
令 p 为每次访问发生本题所计“需要 I/O 的缺页”的概率, 表示该分支的完整耗时,已含处理、等待与完成原访问的成本:
如果题目给的是在正常访问之外增加的缺页代价 C,应写成 。两式只是成本定义不同,不能把额外代价当完整耗时,也不能重复计费。
经典例题:
正常访存 ,缺页分支完整耗时 。
为使性能下降控制在 10% 以内(),缺页率 的容忍上限为多少?解:
即平均约每 40 万次访问才容许一次本题定义的 I/O 缺页;不能把这个阈值套到成本较低的所有 minor fault 上。
八、高频考点自测(20 问 20 答)
- 同一进程中,逻辑上相邻的虚拟页,在物理内存中是否一定相邻?
- 不一定。分页的核心价值正是打破物理连续性约束,各虚拟页可离散分布在任何空闲物理页框中。
- 为什么大而稀疏的地址空间适合多级页表?
- 线性页表需为整个虚拟范围保留条目;多级结构可省去完全未使用区域的下级表。仍要保留目录与实际分配的页表页,不是零开销。
- TLB Miss 发生时,物理页一定不在内存中吗?
- 不一定。TLB Miss 只是没找到地址映射缓存;若页表确认页面已驻留且允许访问,补充映射即可。
- 哪种页面置换算法会出现 Belady 异常?哪种绝对不会?
- FIFO 会出现 Belady 异常;LRU 与 OPT 具备栈式算法包含性质,数学上严格证明绝不会出现。
- 页表项中的 Dirty Bit 有什么用?
- 帮助判断回收时是否要保留最新内容。已有有效后备副本的干净页可免写回;仍需保留且没有最新副本的脏页要安排保存。
- 写时复制(COW)如何防止父子进程数据冲突?
fork()后初始将共享物理页设为只读。任一方写数据时触发写保护陷阱,内核现场拷贝新物理页并恢复可写。
- 跨过对象边界或跨过一页,是否一定触发段错误?
- 都不一定。普通页保护不知道 C 对象边界,跨页后下一页也可能合法。是否立即报错不能证明程序是否正确。
- 时钟置换算法是如何近似模拟 LRU 的?
- 利用硬件自动置 1 的 Use Bit,指针环形扫描,遇到 1 清为 0 给第二次机会,遇到 0 淘汰,兼顾了极低开销与高命中率。
- 什么是颠簸(Thrashing)?发生颠簸时增加 CPU 核数有用吗?
- 频繁换出马上又要使用的页,程序主要在等待换页。增加 CPU 通常无法解决页框不足;减少活跃并发、改善访问模式或增加内存可能更有效。
- 多级页表中,顶级页目录必须连续存放在物理内存中吗?
- 本篇“两级、每张表一页”的硬件模型中,顶级表位于一个驻留物理页框,下级每张表也各占一页;不同表所在页框不必相邻。其他组织必须结合硬件查找规则判断。
- ASID 字段解决了上下文切换时的什么问题?
- 区分不同进程的 TLB 缓存项,避免每次切换进程都必须执行高开销的清空 TLB(Flush)操作。
- VSZ 高达 400 GB 的进程,为什么没有导致系统物理内存爆炸?
- 大量虚拟范围可以预留而不驻留;VSZ 与 RSS 统计对象不同。页表及管理结构仍有成本,共享页也使各进程 RSS 的简单相加不能代表独占物理用量。
- 最佳匹配为什么不属于页面置换?
- Best Fit 从空闲块中选最小的足够大块;页面置换从已驻留页中选择牺牲页。一个决定分配位置,一个决定淘汰对象。
- 空闲 20 B、30 B,为什么申请 40 B 可能失败?
- 需要连续块;两块若在地址上不相邻,不能直接合成 50 B。若相邻且可合并,结果才不同。
- Best Fit 选出 30 B 块满足 24 B,余下 6 B 一定是内部碎片吗?
- 不一定。成功分割后它是空闲余块;若小于最小空闲块要求而被一起分配,未使用余量才留在已分配块内部。
- 伙伴系统允许合并所有相邻等大的空闲块吗?
- 不允许,必须是同一个父块二分得到的那一对伙伴。
- 为什么
free(p)不传长度,RSS 也可能不下降?- 分配器通过元数据找回块大小,并可把块保留在自己的空闲结构中复用;这并不要求立即向 OS 归还整页。
- PDE 的 PFN 与 PTE 的 PFN 指向同一种用途的页吗?
- 本篇两级模型中,前者指向下级页表页,后者指向目标数据页;应分清“表项地址”与“被映射数据的地址”。
- COW 写保护异常为什么可以恢复,写真正只读代码页却通常不行?
- 内核记录的区域语义不同。COW 对象逻辑上允许私有写,只是硬件暂时禁止直接写共享页;真正只读映射没有这个授权。
- 缺页处理结束为什么要重试原指令,而不是执行下一条?
- 引起异常的取指或读写尚未完成,跳过会丢失程序本来应执行的操作。I/O 完成使进程就绪,也不等于它立即获得 CPU。